Skip to main content

Orders Overview

The Patient Portal Orders API lets the authenticated patient list the orders tied to their own cases. The endpoint is self-only: the JWT subject is the only patient whose records are returned, scoped to the cases owned by that patient in the calling organization.

Endpoints

#MethodPathPurpose
1GET/api/v1/users/me/ordersList the patient's orders (optionally filtered by case)

Related resources: Medications (/me/medications) and Payments (/me/payments).

Authentication

Every endpoint requires a successful /verify-otp exchange first.

HeaderRequiredDescription
cv-api-keyYesTenant API key. Resolves the calling organization. Missing → 400 VALIDATION_ERROR.
AuthorizationYesBearer <accessToken> from POST /api/v1/users/auth/verify-otp. Missing or malformed → 401.

The patientPortalAuth() middleware enforces token type patient-portal, JWT/cv-api-key org-match, and that the user still exists. Any failure is collapsed to 401 VALIDATION_ERROR "Invalid or expired token".

Permission Matrix

ActionAllowed when…
List own ordersAlways (filtered to the patient's cases in the calling org).
List a specific case's ordersThe case is owned by the patient (submitterId) and belongs to the calling org. Otherwise → 403.

When caseId is omitted the server resolves the patient's own case ids in the calling organization first; if the patient has no cases, the response data array is [] and nextCursor is null.

Response Envelope

The list is wrapped under orders alongside the pagination cursor:

{
"status": 200,
"success": true,
"data": {
"orders": [ "..." ],
"nextCursor": "<id> | null"
}
}

Error responses follow:

{ "status": 400, "success": false, "error": "<message>", "code": "<CODE>" }

Query Parameters

FieldTypeRequiredNotes
caseIdstring (UUID)NoRestrict the list to a single case owned by the patient. Verified via ensurePatientOwnsCase — if the case does not belong to the patient or to the calling org, returns 403.
limitintegerNo1100. Defaults to 20. Coerced from string.
afterstring (UUID)NoCursor — the last id from the previous page. The server skips that row and returns the next page.

Pagination

Cursor-based over the row id, ordered by createdAt descending (newest first):

  1. Request page 1 without after. The server returns up to limit items plus nextCursor.
  2. If nextCursor is non-null, pass it as after=<nextCursor> to fetch the next page.
  3. When the server has no more rows, nextCursor is null.

The cursor is the last item's id. Internally the server takes limit + 1 rows, drops the extra, and emits its id as the cursor — so a null cursor unambiguously means "no more pages."

Object Shapes

Order

Returned by GET /me/orders.

FieldTypeNotes
idstring (UUID)Order.id.
statusenumOne of DRAFT, PLACED, PROCESSING, HOLD, SHIPPED, OUT_FOR_DELIVERY, DELIVERED, RETURNED, CANCELLED, ERROR, TRANSIT_FAILURE.
trackingIdstring | nullCarrier tracking id, if assigned.
externalIdstring | nullId from the upstream pharmacy / fulfillment system.
caseIdstring (UUID)The case the order belongs to.
createdAtISO-8601 datetimeServer-generated.
medicationsArray<{ medicine, dosage, dosingFrequency }>Flattened from the order's CaseDecision.medInfo. Empty array if the order has no decision or no med info.

Server-Side Behaviors and Defaults

  • Tenant + ownership scoping. With or without caseId, the result is restricted to cases where submitterId = userId and organizationId = req.patientOrganization.id. There is no cross-tenant or cross-patient surface.
  • caseId is pre-validated. When supplied, ensurePatientOwnsCase runs before the list query; failure short-circuits to 403.
  • Default limit. 20. Maximum 100. The validator coerces string → number.
  • Cursor semantics. after is the last row's id from the previous page; the server uses cursor: { id: after }, skip: 1 and asks for limit + 1 rows to detect end-of-results.
  • Empty patient. If the patient has no cases in the calling org (and no caseId was supplied), the endpoint returns { orders: [], nextCursor: null } — no error.

Security Properties

  • Tenant isolation. Case-id resolution and the row-level filter both pin organizationId to the calling org from cv-api-key.
  • Ownership isolation. Case-id resolution pins submitterId to the JWT subject; caseId queries additionally pass through ensurePatientOwnsCase.
  • Uniform 403. "Doesn't exist", "not yours", and "wrong tenant" all collapse to the same 403 VALIDATION_ERROR "You do not have access to this case" so case ids cannot be probed.
  • Token type pinned. Only JWTs with type: 'patient-portal' reach the handler.
  • Cross-tenant defense. The JWT's organizationId is verified against the cv-api-key-resolved org on every call.
  • No write surface. The endpoint is read-only.

Integrator Guidance

  • Refresh proactively. Refresh the access token via /refresh-token before the 15-minute expiry.
  • Listing strategy. Use ?caseId= when surfacing orders within a single-case view; omit it for an account-wide list.
  • Paginate forward only. The cursor moves forward through the sort order — there is no before cursor.
  • Treat shapes as endpoint-specific. A medication embedded inside an Order (order.medications[]) has only medicine / dosage / dosingFrequency. The richer per-row shape (with prescriber, refillCount, etc.) is only available on GET /me/medications.
  • Empty medications[] on an order is normal — it just means the underlying CaseDecision had no medInfo rows.
  • Treat 403 as "no access, may or may not exist". Do not display case-id-specific debug text.